How to Write API Tests in .NET: Enhancing Code Quality and Confidence

Thorstein Løkensgard
Written by 
Thorstein Løkensgard
June 25, 2024
Approximately a 
00
 
min read
Written by 
Thorstein Løkensgard
June 25, 2024
Approximately a 
14
 
minutes read
Tutorial
Intility has a lot of self-made software, and most of this software has an API to make data available. Our current tech stack consists of multiple languages, and .net is one of them. One way to ensure that our applications are running smoothly, is to write good tests and make sure that our code produces the expected result. So why should you tests your API endpoints and how should you do it?

I have worked as a .NET developer for seven years. In my early days, I ignored tests because they caused a lot of delays in development, and I didn't understand the importance of testing your software. My key takeaways today are that tests:

  • Force you to split your code into smaller pieces.
  • Make your code easier to read.
  • Make it easier for your co-workers to contribute.
  • Make it easier to refactor.
  • Make it easier to pick up a project you haven't touched for a long time.
  • Make you more confident in your code.

All the things mentioned above will make it easier for you and the people around you to do their job, while at the same time improving the quality of your code. Therefore, I would like to show you two ways to test a controller.

The Controller

Let's establish an easy controller that uses Entity Framework and has the ability to create and return a resource.

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class CustomersController(IntroDemoContext context) : ControllerBase
{
    [HttpGet("{id}", Name = "GetCustomer")]
    [ProducesResponseType(typeof(CustomerDTO), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
    public async Task<IActionResult> GetCustomer([FromRoute] Guid id, CancellationToken cancellationToken = default)
    {
        var customer = await context.Customers
            .AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);

        if (customer is null)
        {
            return Problem("Resource not found", statusCode: 404);
        }

        return Ok(new CustomerDTO(customer));
    }

    [HttpPost]
    [ProducesResponseType(typeof(CustomerDTO), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
    public async Task<IActionResult> PostCustomer([FromBody] NewCustomerDTO newCustomerDTO, CancellationToken cancellationToken = default)
    {
        var duplicateName = await context.Customers
            .AsNoTracking()
            .FirstOrDefaultAsync(x => x.Name.Equals(newCustomerDTO.Name), cancellationToken: cancellationToken);

        if (duplicateName is not null)
        {
            return Problem($"An Customer with the name: {newCustomerDTO.Name} already exists", statusCode: 400);
        }

        var customer = new Customer(newCustomerDTO);

        context.Customers.Add(customer);
        await context.SaveChangesAsync(cancellationToken);

        return CreatedAtRoute(nameof(GetCustomer), new { version = "1", id = customer.Id }, new CustomerDTO(customer));
    }
}

Unit Tests

Before we start with the actual controller tests, lets create an easy helper for the Entity Framework DbContext. For the purpose of this guide, I'm going to use an in-memory database, but this is generally discouraged by Microsoft.

internal static class DatabaseHelper
{
    internal static IntroDemoContext SetupNewDatabase()
    {
        // Use new guid as name to make sure that there is no conflicts with parallel tests.
        var options = new DbContextOptionsBuilder<IntroDemoContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;

        return new IntroDemoContext(options);
    }
}

For the actual unit tests, let's create an xUnit class with a private readonly Faker for creating customers. I'm using a NuGet package called Bogus, which allows me to create resources with random data.

public class CustomersControllerTests
{
    private readonly Faker<Customer> _faker = new Faker<Customer>()
              .RuleFor(x => x.Name, f => f.Name.FirstName())
              .RuleFor(x => x.Id, f => f.Random.Guid());
}

Lets go ahead and create the first test for retrieving one customer.

  • Give it a descriptive name.
  • Create the expected DbContext and add a customer to the database.
  • Create a new CustomerController.
  • Retrieve the customer and check that the returned result is correct.
[Fact]
public async Task GetCustomer_ReturnsOkResult()
{
    // Arrange
    var customer = _faker.Generate();
    var context = DatabaseHelper.SetupNewDatabase();

    // Add data to database
    context.Customers.AddRange(customer);
    await context.SaveChangesAsync();

    var sut = new CustomersController(context);

    // Act
    var result = await sut.GetCustomer(customer.Id);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var responseData = okResult.Value as CustomerDTO;

    Assert.NotNull(responseData);
    Assert.Equal(customer.Id, responseData.Id);
    Assert.Equal(customer.Name, responseData.Name);
}

We would also like to test the creation of a new customer.

  • Give it a descriptive name.
  • Create the expected DbContext.
  • Create a new CustomerController.
  • Post a new Customer and check that the returned result is as expected.
[Fact]
public async Task PostCustomer_ReturnsCreatedResult_WhenCustomerIsCreated()
{
    // Arrange
    var context = DatabaseHelper.SetupNewDatabase();
    var sut = new CustomersController(context);

    var newCustomerDTO = new NewCustomerDTO()
    {
        Name = "Foo"
    };

    // Act
    var result = await sut.PostCustomer(newCustomerDTO);

    // Assert
    var okResult = Assert.IsType<CreatedAtRouteResult>(result);
    var responseData = okResult.Value as CustomerDTO;

    Assert.NotNull(responseData);
    Assert.Equal(newCustomerDTO.Name, responseData.Name);
}

Integration Tests

For integration tests, there is a bit more configuration required, but they will take a higher approach and test larger portions of the application in one go. We have to use WebApplicationFactory and customize it to work with both authentication and Entity Framework.

To make the authentication work, we will have to create an AuthHandler where we accept custom claims.

internal class TestAuthHelper(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        Claim[] claims =
        [
            new Claim(ClaimTypes.Name, "FirstName LastName"),
            new Claim(ClaimConstants.ObjectId, Guid.NewGuid().ToString())
        ];

        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");

        var result = AuthenticateResult.Success(ticket);

        return Task.FromResult(result);
    }
}

We also need to create a helper for the database context, and once again I will use an in-memory database. The in-memory database is easy to use and it works without any config in GitLab/GitHub/Azure DevOps pipelines. In this class, I'm using a lock to ensure that the database initialization is thread-safe.

internal static class DatabaseHelper
{
    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    internal static void InitializeDatabase(IntroDemoContext context)
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                context.Database.EnsureDeleted();
                context.Database.EnsureCreated();

                _databaseInitialized = true;
            }
        }
    }
}

Once we have created most of the configuration, we can begin customizing the WebApplicationFactory. Every service applied in this factory will override the services specified in program.cs. Therefore, we will add our custom authentication and in-memory database here. All other services in program.cs will behave as they would if the application were started manually, unless you override them.

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Test");
        builder.ConfigureServices(services =>
        {
            services.AddAuthentication("Test")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHelper>("Test", options => { });

            var dbDescriptor = services.SingleOrDefault(
            d => d.ServiceType == typeof(DbContextOptions<IntroDemoContext>));
            if (dbDescriptor != null)
            {
                services.Remove(dbDescriptor);
            }

            var databaseName = Guid.NewGuid().ToString();
            services.AddDbContext<IntroDemoContext>(options =>
            {
                options.UseInMemoryDatabase(databaseName);
            });

            var serviceProvider = services.BuildServiceProvider();

            using var scope = serviceProvider.CreateScope();
            var scopedServices = scope.ServiceProvider;
            var databaseContext = scopedServices.GetRequiredService<IntroDemoContext>();

            try
            {
                DatabaseHelper.InitializeDatabase(databaseContext);
            }
            catch (Exception ex)
            {
                Log.Error(ex, "An error occured while initializing the database");
            }
        });
    }
}

When the CustomWebApplication is ready, we can create the CustomerControllerTests class and use our custom config. I'm still using Bogus to generate resources and the factory to actually call the endpoints.

Lets create the tests we want:

  • Give them a descriptive name.
  • Create the expected DbContext and add a customers to the database.
  • Create the httpClient used to call the endpoints.
  • Retrieve the customer and check that the returned result is correct.
  • Post a new customer and check that the returned result is correct.
public class CustomerControllerTests(CustomWebApplicationFactory factory) : IClassFixture<CustomWebApplicationFactory>
{
    private readonly Faker<Customer> _faker = new Faker<Customer>()
              .RuleFor(x => x.Name, f => f.Name.FirstName())
              .RuleFor(x => x.Id, f => f.Random.Guid());

    [Fact]
    public async Task GetCustomers_ReturnsOkResult()
    {
        // Arrange
        var httpClient = factory.CreateClient();
        var context = factory.Services.GetRequiredService<IntroDemoContext>();
        var customer = _faker.Generate();

        context.Customers.Add(customer);
        await context.SaveChangesAsync();

        //Act
        var response = await httpClient.GetAsync($"api/v1/Customers/{customer.Id}");
        var responseData = await response.Content.ReadFromJsonAsync<CustomerDTO>();

        //Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.NotNull(responseData);
        Assert.Equal(customer.Id, responseData.Id);
        Assert.Equal(customer.Name, responseData.Name);
    }

    [Fact]
    public async Task PostCustomer_ReturnsCreatedResult_WhenCustomerIsCreated()
    {
        // Arrange
        var httpClient = factory.CreateClient();

        var newCustomerDTO = new NewCustomerDTO()
        {
            Name = "Foo"
        };

        // Act
        var response = await httpClient.PostAsJsonAsync("api/v1/Customers", newCustomerDTO);
        var responseData = await response.Content.ReadFromJsonAsync<CustomerDTO>();

        //Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(responseData);
        Assert.Equal(newCustomerDTO.Name, responseData.Name);
    }
}

Conclusion

Testing your API endpoints is essential to ensure that a system is manageable and works as intended. It will force you and your colleagues to think more about the code structure and increase the overall confidence. You can mix and match both unit tests and integration tests, where unit tests will ensure that components work as expected, and integration tests provide a higher-level assessment of the entire application. Remember that AI is a very good tool for writing tests, and with just a few good prompts it will be able to write pretty good tests and also a lot of configuration.

Table of contents

if want_updates == True

follow_intility_linkedin

Other articles